Repository Public
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { FilmActions } from "@/components/FilmActions";5import { FilmCard } from "@/components/FilmCard";6import { HeroPlayer } from "@/components/HeroPlayer";7import { getFilm, relatedFilms } from "@/lib/films";8import { dict, fmtDate, fmtDuration, href, type Locale } from "@/lib/i18n";910type P = { params: Promise<{ locale: string; slug: string }> };11const BASE = process.env.PUBLIC_URL ?? "https://clip.spboucher.ai";1213export async function generateMetadata({ params }: P): Promise<Metadata> {14 const { locale, slug } = await params;15 const film = await getFilm(locale as Locale, slug);16 if (!film) return { title: "Not found", robots: { index: false } };17 const path = `/film/${film.slug}`;18 return {19 title: film.title, description: film.synopsis,20 alternates: { canonical: href(locale as Locale, path), languages: { en: path, fr: `/fr${path}` } },21 openGraph: {22 type: "video.other", title: film.title, description: film.synopsis, url: href(locale as Locale, path),23 images: [{ url: film.poster, width: 720, height: 1280, alt: film.title }],24 videos: [{ url: `${BASE}${film.video}`, type: "video/mp4", width: film.width, height: film.height }],25 },26 twitter: { card: "summary_large_image", title: film.title, description: film.synopsis, images: [film.poster] },27 };28}2930interface Provenance {31 models?: { role: string; provider: string; model: string }[]; pipeline?: string[]; generatedAt?: string; humanApproval?: string;32 manifest?: { audio?: { narration: string; bed: string; music: string }; shots?: unknown[] };33}3435export default async function FilmPage({ params }: P) {36 const { locale: l, slug } = await params;37 const locale = l as Locale;38 const t = dict(locale);39 const film = await getFilm(locale, slug);40 if (!film) notFound();41 const related = await relatedFilms(locale, film);42 const prov = film.provenance as Provenance;43 const jsonLd = {44 "@context": "https://schema.org", "@type": "VideoObject", name: film.title, description: film.synopsis, inLanguage: film.language === "bilingual" ? ["en", "fr"] : film.language,45 thumbnailUrl: `${BASE}${film.poster}`, contentUrl: `${BASE}${film.video}`, uploadDate: film.publishedAt, duration: `PT${Math.round(film.duration)}S`,46 width: film.width, height: film.height, isFamilyFriendly: true, creator: { "@type": "Organization", name: "CLIP", url: BASE },47 creditText: "AI-generated film", ...(film.series ? { isPartOf: { "@type": "CreativeWorkSeries", name: film.series.title } } : {}),48 };49 const facts: [string, string][] = [50 [t.film.runtime, fmtDuration(film.duration)], [t.film.language, t.langs[film.language] ?? film.language], [t.film.released, fmtDate(film.publishedAt, locale)],51 [t.film.kind, t.film[film.kind]],52 ];5354 return (55 <article className="py-6 md:py-10">56 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }} />57 <div className="grid gap-8 md:grid-cols-[auto_minmax(0,1fr)] md:gap-14">58 <div className="mx-auto w-full max-w-[420px] md:sticky md:top-20 md:w-[min(36vw,420px)] md:self-start">59 <div className="frame relative aspect-[9/16]"><HeroPlayer film={film} labels={t.player} locale={locale} loop={false} /></div>60 </div>6162 <div className="min-w-0">63 {film.series && (64 <Link href={href(locale, `/series/${film.series.slug}`)} className="kicker accent-text" style={{ "--a": film.series.accent } as React.CSSProperties}>{film.series.title}{film.episode ? ` · ${t.film.episode} ${film.episode}` : ""}</Link>65 )}66 <h1 className="display mt-3 text-[clamp(2.6rem,6vw,5rem)]">{film.title}</h1>67 {film.titleAlt !== film.title && <p className="mt-2 quote text-2xl text-[var(--color-faint)]" lang={locale === "fr" ? "en" : "fr"}>{film.titleAlt}</p>}6869 <p className="mt-5 inline-flex items-center gap-2 rounded-full border border-[var(--color-line)] px-3 py-1 text-xs text-[var(--color-muted)]">70 <span className="h-1.5 w-1.5 rounded-full bg-[var(--color-lamp)]" />{t.film.aiDisclosure}71 </p>7273 <h2 className="label mt-8">{t.film.synopsis}</h2>74 <p className="prose-film mt-2 text-lg">{film.synopsis}</p>75 {film.takeaway && (<><h2 className="label mt-8">{t.film.takeaway}</h2><p className="mt-2 max-w-[40ch] border-l border-[var(--color-lamp)] pl-4 quote text-2xl">{film.takeaway}</p></>)}7677 <div className="mt-8"><FilmActions film={film} labels={t.film} layout="row" /></div>7879 <dl className="mt-10 grid grid-cols-2 gap-x-6 gap-y-5 border-t border-[var(--color-line-soft)] pt-6 sm:grid-cols-4">80 {facts.map(([k, v]) => <div key={k}><dt className="label">{k}</dt><dd className="mt-1 text-sm">{v}</dd></div>)}81 </dl>82 {film.tags.length > 0 && <div className="mt-6 flex flex-wrap gap-2">{film.tags.map((tag) => <Link key={tag.slug} href={href(locale, `/explore?tag=${tag.slug}`)} className="chip">{tag.label}</Link>)}</div>}8384 {film.sources.length > 0 && (85 <section className="mt-10 border-t border-[var(--color-line-soft)] pt-6" aria-labelledby="sources">86 <h2 id="sources" className="display text-2xl">{t.film.factChecked}</h2>87 <p className="mt-1 text-sm text-[var(--color-faint)]">{t.film.factCheckedBody}</p>88 <ol className="mt-4 space-y-4">89 {film.sources.map((s, i) => (90 <li key={i} className="text-sm">91 <p className="text-[var(--color-text)]">{s.claim}</p>92 <ul className="mt-1 space-y-0.5">{s.sources.map((x) => <li key={x.url}><a href={x.url} rel="noopener noreferrer nofollow" target="_blank" className="break-words text-[var(--color-muted)] underline decoration-[var(--color-line)] hover:text-[var(--color-accent)]">{x.title || x.url}</a></li>)}</ul>93 </li>94 ))}95 </ol>96 </section>97 )}9899 <section className="mt-10 border-t border-[var(--color-line-soft)] pt-6" aria-labelledby="prov">100 <h2 id="prov" className="display text-2xl">{t.film.provenance}</h2>101 <p className="mt-1 max-w-[62ch] text-sm text-[var(--color-faint)]">{t.film.aiDisclosureLong}</p>102 <dl className="mt-5 grid gap-5 text-sm sm:grid-cols-2">103 <div><dt className="label">{t.film.models}</dt><dd className="mt-2 space-y-1">{(prov.models ?? []).map((m) => <p key={m.role}><span className="text-[var(--color-muted)]">{m.role}</span><br /><span className="mono">{m.provider} · {m.model}</span></p>)}</dd></div>104 <div>105 <dt className="label">{t.film.pipeline}</dt><dd className="mt-2 text-[var(--color-muted)]">{(prov.pipeline ?? []).join(" → ")}</dd>106 <dt className="label mt-4">{t.film.approval}</dt><dd className="mt-2 text-[var(--color-muted)]">{prov.humanApproval}</dd>107 {prov.manifest?.audio && (<><dt className="label mt-4">{t.film.audio}</dt><dd className="mt-2 text-[var(--color-muted)]">{t.film.narration}: {prov.manifest.audio.narration}. {t.film.bed}: {prov.manifest.audio.bed}. {t.film.music}: {prov.manifest.audio.music}.</dd></>)}108 </div>109 </dl>110 <p className="mt-6 flex flex-wrap gap-x-5 gap-y-2 text-sm">111 <a href={`/api/films/${film.slug}`} className="text-[var(--color-muted)] underline decoration-[var(--color-line)] hover:text-[var(--color-accent)]">JSON</a>112 <Link href={href(locale, `/report?film=${film.slug}`)} className="text-[var(--color-muted)] underline decoration-[var(--color-line)] hover:text-[var(--color-accent)]">{t.film.report}</Link>113 </p>114 </section>115 </div>116 </div>117118 {related.length > 0 && (119 <section className="mt-16" aria-labelledby="related">120 <h2 id="related" className="display text-3xl md:text-4xl">{t.film.related}</h2>121 <div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">{related.map((r) => <FilmCard key={r.film.id} film={r.film} locale={locale} note={r.reason} />)}</div>122 </section>123 )}124 </article>125 );126}127